package com.bumptech.glide.load.resource.bitmap;

import android.annotation.TargetApi;
import android.content.res.AssetFileDescriptor;
import android.graphics.Bitmap;
import android.media.MediaMetadataRetriever;
import android.os.Build;
import android.os.Build.VERSION_CODES;
import android.os.ParcelFileDescriptor;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.util.Log;

import com.bumptech.glide.load.Option;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.ResourceDecoder;
import com.bumptech.glide.load.engine.Resource;
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool;
import com.bumptech.glide.request.target.Target;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.security.MessageDigest;

/**
 * Decodes video data to Bitmaps from {@link ParcelFileDescriptor}s and
 * {@link AssetFileDescriptor}s.
 *
 * @param <T> The type of data, currently either {@link ParcelFileDescriptor} or
 *            {@link AssetFileDescriptor}.
 */
public class VideoDecoder<T> implements ResourceDecoder<T, Bitmap> {
    private static final String TAG = "VideoDecoder";

    /**
     * A constant indicating we should use whatever frame we consider best, frequently not the first
     * frame.
     */
    public static final long DEFAULT_FRAME = -1;

    /**
     * Matches the behavior of {@link MediaMetadataRetriever#getFrameAtTime(long)}.
     */
    @VisibleForTesting
    static final int DEFAULT_FRAME_OPTION = MediaMetadataRetriever.OPTION_CLOSEST_SYNC;

    /**
     * A long indicating the time position (in microseconds) of the target frame which will be
     * retrieved. {@link MediaMetadataRetriever#getFrameAtTime(long)} is used to
     * extract the video frame.
     *
     * <p>When retrieving the frame at the given time position, there is no guarantee that the data
     * source has a frame located at the position. When this happens, a frame nearby will be returned.
     * If the long is negative, time position and option will ignored, and any frame that the
     * implementation considers as representative may be returned.
     */
    public static final Option<Long> TARGET_FRAME = Option.disk(
            "com.bumptech.glide.load.resource.bitmap.VideoBitmapDecode.TargetFrame", DEFAULT_FRAME,
            new Option.CacheKeyUpdater<Long>() {
                private final ByteBuffer buffer = ByteBuffer.allocate(Long.SIZE / Byte.SIZE);

                @Override
                public void update(@NonNull byte[] keyBytes, @NonNull Long value,
                                   @NonNull MessageDigest messageDigest) {
                    messageDigest.update(keyBytes);
                    synchronized (buffer) {
                        buffer.position(0);
                        messageDigest.update(buffer.putLong(value).array());
                    }
                }
            });

    /**
     * An integer indicating the frame option used to retrieve a target frame.
     *
     * <p>This option will be ignored if {@link #TARGET_FRAME} is not set or is set to
     * {@link #DEFAULT_FRAME}.
     *
     * @see MediaMetadataRetriever#getFrameAtTime(long, int)
     */
    // Public API.
    @SuppressWarnings("WeakerAccess")
    public static final Option<Integer> FRAME_OPTION = Option.disk(
            "com.bumptech.glide.load.resource.bitmap.VideoBitmapDecode.FrameOption",
            /*defaultValue=*/ MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
            new Option.CacheKeyUpdater<Integer>() {
                private final ByteBuffer buffer = ByteBuffer.allocate(Integer.SIZE / Byte.SIZE);

                @Override
                public void update(@NonNull byte[] keyBytes, @NonNull Integer value,
                                   @NonNull MessageDigest messageDigest) {
                    //noinspection ConstantConditions public API, people could have been doing it wrong
                    if (value == null) {
                        return;
                    }
                    messageDigest.update(keyBytes);
                    synchronized (buffer) {
                        buffer.position(0);
                        messageDigest.update(buffer.putInt(value).array());
                    }
                }
            }
    );

    private static final MediaMetadataRetrieverFactory DEFAULT_FACTORY =
            new MediaMetadataRetrieverFactory();

    private final MediaMetadataRetrieverInitializer<T> initializer;
    private final BitmapPool bitmapPool;
    private final MediaMetadataRetrieverFactory factory;

    public static ResourceDecoder<AssetFileDescriptor, Bitmap> asset(BitmapPool bitmapPool) {
        return new VideoDecoder<>(bitmapPool, new AssetFileDescriptorInitializer());
    }

    public static ResourceDecoder<ParcelFileDescriptor, Bitmap> parcel(BitmapPool bitmapPool) {
        return new VideoDecoder<>(bitmapPool, new ParcelFileDescriptorInitializer());
    }

    VideoDecoder(
            BitmapPool bitmapPool, MediaMetadataRetrieverInitializer<T> initializer) {
        this(bitmapPool, initializer, DEFAULT_FACTORY);
    }

    @VisibleForTesting
    VideoDecoder(
            BitmapPool bitmapPool,
            MediaMetadataRetrieverInitializer<T> initializer,
            MediaMetadataRetrieverFactory factory) {
        this.bitmapPool = bitmapPool;
        this.initializer = initializer;
        this.factory = factory;
    }

    @Override
    public boolean handles(@NonNull T data, @NonNull Options options) {
        // Calling setDataSource is expensive so avoid doing so unless we're actually called.
        // For non-videos this isn't any cheaper, but for videos it safes the redundant call and
        // 50-100ms.
        return true;
    }

    @Override
    public Resource<Bitmap> decode(
            @NonNull T resource, int outWidth, int outHeight, @NonNull Options options)
            throws IOException {
        long frameTimeMicros = options.get(TARGET_FRAME);
        if (frameTimeMicros < 0 && frameTimeMicros != DEFAULT_FRAME) {
            throw new IllegalArgumentException(
                    "Requested frame must be non-negative, or DEFAULT_FRAME, given: " + frameTimeMicros);
        }
        Integer frameOption = options.get(FRAME_OPTION);
        if (frameOption == null) {
            frameOption = DEFAULT_FRAME_OPTION;
        }
        DownsampleStrategy downsampleStrategy = options.get(DownsampleStrategy.OPTION);
        if (downsampleStrategy == null) {
            downsampleStrategy = DownsampleStrategy.DEFAULT;
        }

        final Bitmap result;
        MediaMetadataRetriever mediaMetadataRetriever = factory.build();
        try {
            initializer.initialize(mediaMetadataRetriever, resource);
            result =
                    decodeFrame(
                            mediaMetadataRetriever,
                            frameTimeMicros,
                            frameOption,
                            outWidth,
                            outHeight,
                            downsampleStrategy);

        } catch (RuntimeException e) {
            // MediaMetadataRetriever APIs throw generic runtime exceptions when given invalid data.
            throw new IOException(e);
        } finally {
            mediaMetadataRetriever.release();
        }

        return BitmapResource.obtain(result, bitmapPool);
    }

    @Nullable
    private static Bitmap decodeFrame(
            MediaMetadataRetriever mediaMetadataRetriever,
            long frameTimeMicros,
            int frameOption,
            int outWidth,
            int outHeight,
            DownsampleStrategy strategy) {
        Bitmap result = null;
        // Arguably we should handle the case where just width or just height is set to
        // Target.SIZE_ORIGINAL. Up to and including OMR1, MediaMetadataRetriever defaults to setting
        // the dimensions to the display width and height if they aren't specified (ie
        // getScaledFrameAtTime is not used). Given that this is an optimization only if
        // Target.SIZE_ORIGINAL is not used and not using getScaledFrameAtTime ever would match the
        // behavior of Glide in all versions of Android prior to OMR1, it's probably fine for now.
        if (Build.VERSION.SDK_INT >= VERSION_CODES.O_MR1
                && outWidth != Target.SIZE_ORIGINAL
                && outHeight != Target.SIZE_ORIGINAL
                && strategy != DownsampleStrategy.NONE) {
            result =
                    decodeScaledFrame(
                            mediaMetadataRetriever, frameTimeMicros, frameOption, outWidth, outHeight, strategy);
        }

        if (result == null) {
            result = decodeOriginalFrame(mediaMetadataRetriever, frameTimeMicros, frameOption);
        }

        return result;
    }

    @TargetApi(VERSION_CODES.O_MR1)
    private static Bitmap decodeScaledFrame(
            MediaMetadataRetriever mediaMetadataRetriever,
            long frameTimeMicros,
            int frameOption,
            int outWidth,
            int outHeight,
            DownsampleStrategy strategy) {
        try {
            int originalWidth =
                    Integer.parseInt(
                            mediaMetadataRetriever.extractMetadata(
                                    MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH));
            int originalHeight =
                    Integer.parseInt(
                            mediaMetadataRetriever.extractMetadata(
                                    MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT));
            int orientation =
                    Integer.parseInt(
                            mediaMetadataRetriever.extractMetadata(
                                    MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION));

            if (orientation == 90 || orientation == 270) {
                int temp = originalWidth;
                //noinspection SuspiciousNameCombination
                originalWidth = originalHeight;
                originalHeight = temp;
            }

            float scaleFactor =
                    strategy.getScaleFactor(originalWidth, originalHeight, outWidth, outHeight);

            int decodeWidth = Math.round(scaleFactor * originalWidth);
            int decodeHeight = Math.round(scaleFactor * originalHeight);

            return mediaMetadataRetriever.getScaledFrameAtTime(
                    frameTimeMicros, frameOption, decodeWidth, decodeHeight);
        } catch (Throwable t) {
            // This is aggressive, but we'd rather catch errors caused by reading and/or parsing metadata
            // here and fall back to just decoding the frame whenever possible. If the exception is thrown
            // just from decoding the frame, then it will be thrown and exposed to callers by the method
            // below.
            if (Log.isLoggable(TAG, Log.DEBUG)) {
                Log.d(TAG, "Exception trying to decode frame on oreo+", t);
            }

            return null;
        }
    }

    private static Bitmap decodeOriginalFrame(
            MediaMetadataRetriever mediaMetadataRetriever,
            long frameTimeMicros,
            int frameOption) {
        return mediaMetadataRetriever.getFrameAtTime(frameTimeMicros, frameOption);
    }

    @VisibleForTesting
    static class MediaMetadataRetrieverFactory {
        public MediaMetadataRetriever build() {
            return new MediaMetadataRetriever();
        }
    }

    @VisibleForTesting
    interface MediaMetadataRetrieverInitializer<T> {
        void initialize(MediaMetadataRetriever retriever, T data);
    }

    private static final class AssetFileDescriptorInitializer
            implements MediaMetadataRetrieverInitializer<AssetFileDescriptor> {

        @Override
        public void initialize(MediaMetadataRetriever retriever, AssetFileDescriptor data) {
            retriever.setDataSource(data.getFileDescriptor(), data.getStartOffset(), data.getLength());
        }
    }

    // Visible for VideoBitmapDecoder.
    static final class ParcelFileDescriptorInitializer
            implements MediaMetadataRetrieverInitializer<ParcelFileDescriptor> {

        @Override
        public void initialize(MediaMetadataRetriever retriever, ParcelFileDescriptor data) {
            retriever.setDataSource(data.getFileDescriptor());
        }
    }
}
